Skip to content

codegen: an unsigned bound absorbs the element key's sign test (N10) - #14426

Merged
proggeramlug merged 21 commits into
mainfrom
perf/unsigned-bound
Nov 10, 2026
Merged

proggeramlug merged 21 commits into
mainfrom
perf/unsigned-bound

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

CENSUS-residual §5 N10, cause (i): an element read through the canonical key split tested the key's sign (test; mov $-1; cmovns; js, or test; js after the normalizer) and then bounded it with an unsigned compare. The bound already declines every negative key, because a negative i32 is at least 2^31 unsigned and every length, capacity and floor is below 2^31. This PR moves the sign test off the hit path and onto the miss arm. It is codegen only, and wpfacts is unchanged.

D (#14420) and R1 (#14416) are both in main. The branch merges origin/main 682e548 (the measured head) and then 963ffae (N2 #14424 among others), and git merge-tree against it is clean. N10 changes only the guard computation, so it composes with N2's function-level bases.

What changes

  • The read split (expr/index_get/runtime_key.rs, lower_array_index_get_via_canonical_i32_split).
    • When the element tier puts an unsigned bound in front of every element arm (guarded_array::unsigned_bound_covers), the split branches straight into the tier.
    • The tier's miss arm starts with idx <s 0 → runtime-key arm (guarded_array::emit_negative_exit, block ubound.cold). A negative key therefore still reaches the runtime-key arm with its original double (mode 0, the exact property-key read). A non-negative miss still reaches the tier's own cold read.
    • So every key takes the arm it takes today; only the place where the sign is tested moves.
  • Which tiers qualify:
    • the guarded tier: idx <u prefix on the prefix hit, idx <u length and idx <u capacity on the full hit. Diagnostic builds with the observing guard are excluded, because the guard call receives the key.
    • the rep-keyed proven tier when the key is not in the floor (idx <u capacity);
    • the fixed-storage read when the key is not proven in bounds (idx <u len; proven_array::bounds_key).
    • A read whose key is proven in range has no bound and keeps the split's sign test exactly as today.
  • A native integer key (a sitofp/uitofp i32 source: k | 0, an int32 local) is handed over as it is (index_key::normalize_unsigned), with no select(idx >= 0, idx, -1). The two store paths that decline every rejected key onto one helper with the original double get the same: index_set.rs's runtime-key store and StoreIndex::CanonicalOfDouble. Their one bound is idx <u capacity. Every other key form keeps its admission: the normalizer call still returns -1 for NaN, fractions, tags and out-of-range values, -0 still maps to element 0, and the charCodeAt alias keeps its ordered/pristine select.
  • Soundness of the representation. ArrayHeader.length/capacity are u32 slots, and every element tier already compares them unsigned. A key at or above 2^31 (a uitofp source, or -1 from the normalizer) is declined by the bound and is negative as i32, so it takes the runtime-key arm as before.

The evaluate site from the disassembly (qb10, rel2, evaluate$sig, one of the two normalizer sites):

off:  call perry_array_index_normalize_i32 ; test eax,eax ; js <runtime key> ; … ; cmp 0x4(%r12),%eax ; jae <cold>
on:   call perry_array_index_normalize_i32 ; cmp 0x4(%r15),%eax ; jae <cold>

Sign-test census on the chess hot functions (js count in the disassembly, on vs PERRY_UNSIGNED_BOUND=0, qb10 rel2): evaluate 2 → 0, generatePseudo 1 → 0, make 25 → 9, negamax 20 → 12, quiescence 21 → 14, set$generic 5 → 0, set 6 → 2, unmake unchanged. attacked has no sign test on either arm: after D and R1 its reads are in the floor or held by a base. The remaining cmovns in make are number-to-string sign handling (or $0x2d, the '-' character) and ToInt32's slow path, not element bounds.

Check mode, switch, sabotage

  • PERRY_VERIFY_FACTS=1: the tier's cold read, after the negative exit, tests that its key is non-negative. That is the fold's claim that only non-negative keys reach it. The new fact is "unsigned bound" (verify_facts::Fact::UnsignedBound).
  • PERRY_UNSIGNED_BOUND=0: every read and store keeps its sign test in front of the bound (today's code). It is a compile-cache input (compile_env.rs, test unsigned_bound_switch_is_a_cache_input).
  • PERRY_FACTS_SABOTAGE=unsignedbound: the miss arm drops the negative exit. S0 unsigned-bound-negative.ts exits 0 on the correct arm and 86 on the sabotaged arm ("unsigned bound").

Results (instructions:u, on vs PERRY_UNSIGNED_BOUND=0 on the same binary, two runs each, identical; checksums equal)

qb10, the head (rel2 = d4ca346: N10 merged with origin/main 682e548, which contains D, R1, N1a and N1b) (M):

workload on (M) off (M) change
search 250000 25 105.6 25 323.7 −0.86% (−218.1)
perft 4 600.4 605.8 −0.89% (−5.4)
evaluate 100000 1 703.8 1 713.7 −0.58% (−9.9)
legal 10000 969.3 973.6 −0.44% (−4.3)

qb10, the final head (rel3 = fc5284f: merged with origin/main 963ffae, which includes N2 #14424 and N7) (M; posted as a PR comment):

workload on (M) off (M) change
search 250000 24 721.5 24 939.7 −0.87%
perft 4 587.7 593.1 −0.91%
evaluate 100000 1 703.8 1 713.7 −0.58%
legal 10000 787.1 791.4 −0.54%

qb9, rel1 = 3fe1ade: N10 on main 0e35b90 with the D and R1 branches merged locally, before N1a/N1b (M; its own table):

workload on (M) off (M) change
search 250000 25 456.5 25 531.1 −0.29%
perft 4 607.5 608.4 −0.15%
evaluate 100000 1 770.8 1 779.3 −0.48%
legal 10000 975.4 975.9 −0.05%
  • The census estimated (E) search −0.6%, perft −0.3%, evaluate −2.5%, legal −0.3%. Evaluate is below that estimate because D and R1 moved most of evaluate's reads into the floor or under a held base, where there is no split. The head beats rel1 on search, perft and legal because N1's int32 temporaries turn more keys into native ints, whose -1 select now goes away as well.
  • Peak RSS (qb10, /usr/bin/time max RSS, on / off): search 26 880 / 26 880 kB, perft, evaluate and legal 19 456 / 19 456 kB.

Gates

gate result
chess on/off, checksums equal in every run on both hosts (tables above)
parity fixtures qb10 rel2: 19 fixtures × on, off, check mode, PERRY_GC_MOVING_LOOP_POLLS=1, real GC stress on, off and in check mode = 133/133 MATCH (node v26.8.1). qb9 rel1: 17 fixtures, 119/119 MATCH (node v26.12.1). The fixtures are the new test_unsigned_bound, R1's test_range_tightening, D's loop_base/loop_base_gc, C3/C2/B's array-rep fixtures, test_gap_array_index_range_proof and test_guarded_raw_numeric_arrays
GC stress (real) instrumented runtime, PERRY_GC_SCHEDULE_SEED=7 at compile time, PERRY_GC_SCHEDULE_RATE=1 at run time. Forced collections: test_unsigned_bound 8 (4 012 moved objects), loop_base_gc 25 000 (460 087 moved objects, 9 750 512 polls), floor_growth 11, range_tightening 7. The counts are the same with the switch off
S0 qb10 rel2: rc 0, 89 executable controls. qb9 rel1: rc 0, 87. unsigned-bound-negative: correct 0, false 86. Every older arm is unchanged
chess in check mode perft 3, legal 1000, search 20000, evaluate 1000: rc 0, no misses (601 array-rep rows), on both hosts
chess IR on vs off: 1 of 2 modules differs (the engine)
hello / hello2 byte-identical on/off. qb10: md5 f446a6b50365 / 99629d9711db, callgrind Ir 130 794 / 627 004 in all 3 runs. qb9: dc07ba3f94af / d0a428c63c51, Ir 127 131 / 623 920 ×3
parity IR census, on vs off (qb9 rel1) 273 of 2 577 programs differ in IR (every split with a bounded tier, and every widened-key store); 0 compile-rc differences; 11 OUTDIFF. Ten of them have identical IR in both arms: the known nondeterministic and non-compiling set. The eleventh, string/test_issue_9810_virtual_string_indices, fails identically in both arms ("Error: custom prototype", pre-existing); only the code address in the printed stack frame differs
vf, PERRY_VERIFY_FACTS=1 (qb9 rel1) n=1538, json=0, abort=0, rc86=0, crc!=0=2 (the known pair: test_gap_s8b_computed_fields, test_gap_10554_fn_identity_own_module)
lost sites, tssrc (qb9 rel1) on vs off: lost 0, gained 0 (24 552 proven in both)
suites see the next table
-D warnings see the next table
scripts/check_file_size.sh passes; no file of this PR needs an allowlist count (guarded_array.rs 1 327 lines, index_key.rs 620, proven_array.rs 548)
rustfmt own files and hunks only
suites and -D warnings result
qb10, release, at 4ba7832 (the head before the last main merge) perry-codegen 2877 passed, 0 failed; perry-wpfacts 2306 passed, 0 failed; num_seeds_audit 2 passed; perry compile_env 7 passed (with unsigned_bound_switch_is_a_cache_input), 1 failed: codegen_env_vars_are_build_cache_inputs, main's known failure ("shared list must be sorted and unique", FOLLOWUPS); perry-hir 881 passed; perry-runtime 6654 passed, 0 failed
-D warnings (qb10, 4ba7832) perry-hir, perry-wpfacts, perry-codegen, perry: 0 errors
qb9 at 74d10c2 (before the main merge) the same results, except that codegen showed 2875 passed and 1 failed: any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index pinned the split's icmp sge, and 4ba7832 updates it to the miss-arm sign test (icmp slt, ubound.cold). -D warnings 0 errors ×4

The last head, fc5284f, merges origin/main 963ffae (N2 #14424, VR-0 #14423, N7, the Supabase top-level-await fix). The only conflict was the S0 arm list, which is additive. None of those commits touches this PR's files.

Size (every executable section; on vs off, qb9 rel1)

program on off change .text perry_js_text
chess 7 605 594 7 605 630 −36 −256 +220
three.js skin 25 333 123 25 426 171 −93 048 (−0.37%) +5 824 −98 872
tssrc 64 146 053 64 156 865 −10 812 (−0.017%) +1 472 −12 284
Supabase CLI (qb10, rel3 fc5284f) 218 382 965 218 416 490 −33 525 (−0.015%) −3 008 −30 517

Supabase size: on rel1 (qb9) both arms stopped at the macro runner: main was broken from 8999cdb (exit 13, top-level await, #14413) until #14425. The row above is from the head rel3 (which contains 963ffae), compiled via /root/supa-one.sh on qb10; the other rows are from rel1 on qb9.

Departures and what is not done

  • The sign test moves; it is not deleted. "Don't change which path an out-of-range index takes" means a negative key must still reach the runtime-key arm (mode 0: the exact property-key read). The tier's cold read is mode 2 or the absence-proof mode, and the absence-proof mode assumes a non-negative own-slot key. So the miss arm tests the sign once before its cold read, and the hit path has one unsigned compare.
  • In-floor reads keep the split's test. A read proven in the floor has no bound to absorb the test. Their key is proven in [0, floor), so the split's test is redundant there too, but removing it is the "interval proves ≥ 0" case: a different lever, not done here.
  • at$idx_u31 clones, Array.prototype.at and typed arrays are not touched. The clones take a raw non-negative i32 and have no split. at() with a negative index has different semantics (it counts from the end). The typed-array element paths have no sign test in front of their bound in the code here.
  • The S0 sabotage drops the miss arm's exit. It does not make the hot compare signed: the claim that check mode can test is what the cold read receives.
  • Hosts. qb10 was down 13:5x–14:45, so rel1 and its long gates ran on qb9 (owner override). The head (rel2) ran the chess table, peak RSS, the asm census, the fixture/S0/check-mode/hello gates and the suites on qb10. The parity IR census, vf, tssrc lost sites and the size census are from rel1 on qb9. Its N10 code is the same as the head's; the base differs by N1a/N1b and the final D/R1 merge.
  • Not measured: macOS (the disk is nearly full; no Mac build was made).

Ralph Küpper added 21 commits November 10, 2026 08:45
…n a leaving path keeps the narrowed reads (R1 range tightening)
…nts is no coercion; the arrbase fixture grows the board by pushes
…aving-writes

# Conflicts:
#	crates/perry/src/commands/compile/compile_env/tests.rs
…and the admission fixture's loop is not unrolled
# Conflicts:
#	crates/perry-codegen/src/expr/index_get.rs
…s' into perf/unsigned-bound

# Conflicts:
#	scripts/test-verify-facts-s0.py
An element read through the canonical key split tested the key's sign and
then bounded it with an unsigned compare. The tier's bound already declines a
negative key (it is at least 2^31 unsigned), so the sign test moves to the
tier's miss arm: a negative key leaves there for the exact property-key arm,
a non-negative miss keeps the tier's cold read. A widened native key is no
longer mapped to -1 by a select, on reads and on the guarded stores. Reads
whose key is proven in range have no bound and keep the split's test.

PERRY_UNSIGNED_BOUND=0 turns it off (a compile-cache input). Check mode tests
that only non-negative keys reach a tier's cold read (fact "unsigned bound");
PERRY_FACTS_SABOTAGE=unsignedbound drops the exit (S0 unsigned-bound-negative).
Parity fixture test_unsigned_bound.ts covers -1, -0, NaN, 2^31, 2^32-1, 1.5,
string keys, holes and length 0.
# Conflicts:
#	crates/perry-wpfacts/src/gen/walk.rs
#	crates/perry/src/commands/compile/compile_env.rs
#	crates/perry/src/commands/compile/compile_env/tests.rs
#	scripts/test-verify-facts-s0.py
# Conflicts:
#	scripts/test-verify-facts-s0.py
@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@proggeramlug
proggeramlug merged commit eb85710 into main Nov 10, 2026
95 of 102 checks passed
@proggeramlug
proggeramlug deleted the perf/unsigned-bound branch November 10, 2026 17:37
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-measured on the final head fc5284f (this PR merged with origin/main 963ffae, which includes N2 #14424, N7 and VR-0). qb10, instructions:u, on vs PERRY_UNSIGNED_BOUND=0 on the same binary, two identical runs each, checksums equal:

workload on (M) off (M) change
search 250000 24 721.5 24 939.7 −0.87%
perft 4 587.7 593.1 −0.91%
evaluate 100000 1 703.8 1 713.7 −0.58%
legal 10000 787.1 791.4 −0.54%

N10 composes with N2's function-level bases: the gain matches the rel2 table. The release build of this head is clean. The Supabase size census on this head (via /root/supa-one.sh) is queued behind another lane and will follow.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Supabase CLI size on the final head fc5284f (qb10, via /root/supa-one.sh; every executable section, on vs PERRY_UNSIGNED_BOUND=0):

program on off change .text perry_js_text
Supabase CLI 218 382 965 218 416 490 −33 525 (−0.015%) −3 008 −30 517

It shrinks, as chess, skin and tssrc do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant